מבוא למדעי המחשב תירגול 14: נושאים מתקדמים 1
מה היה שבוע שעבר? Backtracking 2
תוכנייה עץ רקורסיה העברת פרמטרים ל- main שאלות ממבחן 3
עץ רקורסיה 4
עץ הקריאות של פונקציה רקורסיבית על מנת לחקור התנהגות של פונקציה רקורסיבית, בעץהקריאות שלה. נוח להתבונן עץ הקריאות של פונקציה רקורסיבית הוא עץ שבו כל קודקוד מייצג את אחת הקריאות לפונקציה. השורש הוא הקריאה הראשונה לפונקציה (כלומר כשקוראים לפונקציה מחוץ לה), והבנים של כל קודקוד הם הקריאות הרקורסיביות שהפונקציה מבצעת במהלך ריצתה. העלים בעץ הקריאות מתאימים למקרי הבסיס של הרקורסיה, כיוון שהם מייצגים ריצות של הפונקציה שאינן מבצעות קריאות רקורסיביות. 5
דוגמאות לעצי קריאות unsigned long factorial(unsigned int n) { if (n == 0) return 1; return n * factorial(n-1); חישוב עצרת: factorial(4) factorial(3) factorial(2) factorial(1) רקורסיה ליניארית! factorial(0) 6
דוגמאות לעצי קריאות unsigned long fibonacci(unsigned int n) { if (n == 0) return 0; if (n == 1) return 1; return fibonacci(n-1) + fibonacci(n-2); פיבונאצ'י: fibonacci(5) fibonacci(4) fibonacci(3) fibonacci(3) fibonacci(2) fibonacci(2) fibonacci(1) fibonacci(2) fibonacci(1) fibonacci(1) fibonacci(0) fibonacci(1) fibonacci(0) fibonacci(1) fibonacci(0) 7
סיבוכיות של אלגוריתמים רקורסיביים סיבוכיות זמן: קשורה למספר הכולל של קריאות רקורסיביות. סיבוכיות הזמן היא סך כל הזמן הדרוש לפונקציה, והוא שווה לסכום הזמן שדורשות כל הקריאות הרקורסיביות יחד. במקרה הפשוט, כל קריאה רקורסיבית מתבצעת בזמן קבוע (1)Θ. במקרה זה הזמן הכולל הוא פשוט (מס' הקריאות הרקורסיביות) Θ. במקרה הכללי צריך להתבונן בעץ הקריאות של הפונקציה הרקורסיבית ולסכום את הזמנים הדרושים לכל הקריאות בעץ. 8
סיבוכיות של אלגוריתמים רקורסיביים סיבוכיותמקום: קשורה לעומק המקסימאלי של הרקורסיה (המספר המקסימאלי של קריאות רקורסיביות שמתקיימות בו זמנית על המחסנית). סיבוכיות המקום היא כמות הזיכרון המקסימאלית שהפונקציה צורכת במהלך ריצתה. כל כניסה רקורסיבית דורשת הקצאת מקום נוסף במחסנית, ואילו כל יציאה מהרקורסיה מפנה זיכרון זה. לכן כמות הזיכרון המקסימאלית שדורשת הפונקציה מתקבלת בדרך כלל כאשר אנו נמצאים בעומק המקסימאלי של הרקורסיה. במקרה הפשוט, כל קריאה רקורסיבית דורשת זיכרון קבוע (1)Θ. במקרה זה הזיכרון הכולל הוא פשוט (עומק הרקורסיה המקסימאלי) Θ. במקרה הכללי יש לבחון את עץ הקריאות, למצוא את הקריאה בעומק המקסימאלי, ולסכום את כמות הזיכרון שתופשות כל הקריאות הרקורסיביות מהשורש ועד עליה. 9
סיבוכיות של פיבונאצ'י סיבוכיותזמן: כל קריאה לפונקציה דורשת מספר קבוע של פעולות, ולכן זמן הריצה הכולל הוא פשוט (מספר הקריאות הרקורסיביות) Θ, שזה גם (מספר הקודקודים בעץ הקריאות) Θ. סיבוכיותמקום: כל קריאה רקורסיבית צורכת כמות קבועה של זיכרון. לכן, השלב בו הכי הרבה זיכרון תפוש מתקבל כאשר אנו נמצאים בעומק המקסימאלי של הרקורסיה במצב זה יש הכי הרבה קריאות על המחסנית. unsigned long fibonacci(unsigned int n) { if (n == 0) return 0; if (n == 1) return 1; return fibonacci(n-1) + fibonacci(n-2); 10
סיבוכיות של פיבונאצ'י סיבוכיות מקום: אם נתבונן בעץ הקריאות של הפונקציה, נבחין שהמסלול הארוך ביותר בעץ מגיע לעומק n. לפיכך, המספר המקסימאלי של קריאות רקורסיביות שמתקיימות בו- זמנית על המחסנית הוא n, וכיוון שכל קריאה רקורסיבית כזו תופשת כמות קבועה של זיכרון, אנו מקבלים שסיבוכיות הזיכרון הינה.Θ(n) n fibonacci(n) fibonacci(n-1) fibonacci(n-1) fibonacci(n-2) fibonacci(n-3) fibonacci(n-3) fibonacci(n-3) 11
סיבוכיות של פיבונאצ'י סיבוכיות זמן: על מנת לקבל את סיבוכיות הזמן של פיבונאצ'י, עלינו לדעת את מספר הקודקודים בעץ הקריאות. הבעיה היא, שקשה לחשב את המספר המדויק של הקודקודים בעץ הזה. במקום זאת, נקבל חסם עליון וחסם תחתון על מספר הקודקודים בעץ. חסם עליון: אנו יודעים שהעומק המקסימאלי של העץ הוא n. כעת, בעץ מלא בעומק n יש 1- n 2 קודקודים, ואילו העץ שלנו איננו מלא ולכן יש בו לכלהיותר 1- n 2 קודקודים. מכאן שמספר הקריאות הרקורסיביות הוא לכל היותר - n 2 1, ולכן זמן הריצה חסום מלמעלה על ידי ) n.t(n)=o(2 12
סיבוכיות של פיבונאצ'י חסם תחתון: אם נתבונן שוב בעץ הקריאות, נראה שהעומק המינימאלישל העץ הוא 2/n. עתה, בעץ מלא בעומק 2/n יש 2/n 1-2 קודקודים, ואילו העץ שלנו מכיל בתוכו את כל העץ הזה, ולכן יש בו לפחות 1-2/n 2 קודקודים. מכאן שמספר הקריאות הרקורסיביות הוא לפחות 2/n 1-2, וזמן הריצה חסום מלמטה על ידי ) 2/n.T(n)=Ω(2 fibonacci(n) n/2 fibonacci(n-1) fibonacci(n-2) fibonacci(n-3) fibonacci(n-3) fibonacci(n-4) fibonacci(n-6) 13
סיבוכיות של factorial() פונקצית העצרת הינה רקורסיה ליניארית. נשים לב שכל קריאה רקורסיבית מקטינה את n באחת, ולכן עומק הרקורסיה הוא.Θ(n) unsigned long factorial(unsigned n) { if (n==0) return 1; return n * factorial(n-1); factorial(4) factorial(3) factorial(2) factorial(1) factorial(0) זמן ריצה: כל קריאה רקורסיבית דורשת מספר קבוע של פעולות, ומתבצעות סה"כ n קריאות רקורסיביות, ולכן זמן הריצה הוא.Θ(n) זיכרון: כל קריאה רקורסיבית דורשת זיכרון בגודל קבוע (שאיננו תלוי ב- n ), והמספר המקסימאלי של קריאות רקורסיביות שמתקיימות בו- זמנית הוא n. לכן סיבוכיות הזיכרון היא Θ(n) גם כן. 14
סיבוכיות של חיפוש בינארי חיפוש בינארי ניתן למימוש כרקורסיה ליניארית. בכל קריאה רקורסיבית מקטינים את תחום החיפוש פי 2 על פי האיבר האמצעי. int binsearch(int a[], int n, int x) { if (n <= 0) return -1; if (a[n/2] == x) return n/2; if (a[n/2] > x) return binsearch(a,n/2,x); else { int pos = binsearch(a+n/2+1, n-n/2-1, x); if (pos == -1) return -1; return pos + n/2 + 1; 15
סיבוכיות של חיפוש בינארי במקרה של חיפוש בינארי, כל קריאה רקורסיבית מקטינה את n פי 2, ולכן עומק הרקורסיה הוא.Θ(log(n)) זמן ריצה: בכל קריאה רקורסיבית מבוצע מספר קבוע של פעולות, ולכן זמן הריצה הוא.Θ(log(n)) דרישות זיכרון: כל קריאה רקורסיבית צורכת זיכרון בגודל קבוע, ולכן סיבוכיות הזיכרון אף היא.Θ(log(n)) 16
העברת פרמטרים ל- main 17
תזכורת מערך של מחרוזות מערך של מחרוזות הוא בעצם מערך של מצביעים לתחילת מחרוזות. בעבר הגדרנו את מערך המחרוזות הבא: char *beatles[4] = { "John Lennon", "Paul McCartney", "George Harrison", "Ringo Starr" ; בקוד זה, כל אחת מארבע המחרוזות בתוך ה-{ הינה מחרוזת קבועה. בתחילת ריצת התוכנית, כל אחת מהן נכתבת לזיכרון המוגן של התוכנית, וכאשר מערך המצביעים מוקצה, כל מצביע במערך מאותחל לכתובת בה נמצאת המחרוזת המתאימה בזיכרון הקבועים. 18
העברת פרמטרים ל-() main ניתן לכתוב את הפונקציה main() כך שתוכל לקבל פרמטרים מהמשתמש בזמן שהוא מעלה את התוכנית. כיצד זה מתבצע? כאשר אנו מריצים את התוכנית מתוך,DOS אנו כותבים את שם התוכנית, ולאחר מכן ניתן להוסיף מספר פרמטרים כרצוננו, מופרדים על ידי רווחים. מערכת ההפעלה מעבירה פרמטרים אלו כמו שהם (כמחרוזות) לפונקציה main() של התוכנית. לדוגמה, חבילת CodeBlocks כולל בין היתר תוכנת DOS שנקראת.gcc תוכנה זו מקבלת שם של קובץ C בשורת הפקודה, והיא מקמפלת אותו ויוצרת קובץ הרצה. הקריאה ל- gcc בחלון ה- DOS נראית כך: C:\CodeBlocks\MinGW\bin> gcc helloworld.c 19
העברת פרמטרים ל-() main על מנת לקבל בתוך הפונקציה main() את הפרמטרים שנשלחו לתוכנית, יש להוסיף בחתימת הפונקציה main() שני פרמטרים: int main(int argc, char *argv[]) { argv הוא מערך של,char* דהיינו מערךשלמחרוזות. פרמטר זה מכיל את רשימת כל המחרוזות שנכתבו בשורת הפקודה, כולל שם התוכנית עצמה. הוא מספר המחרוזות שיש ב- argv, argc כלומר אורך המערך. למשל, בדוגמה של gcc שראינו קודם,.argc==2 המחרוזות ב-[] argv יהיו: argv[0] argv[1] "gcc" helloworld.c" 20
דוגמה פשוטה : התוכנית test מקבלת קלט משורת הפקודה, ומדפיסה אותו int main(int argc, char *argv[]) { int i; for (i=0; i<argc; ++i) printf("argv[%d]: %s\n", i, argv[i]); return 0; C:\> test one two three! argv[0]: test argv[1]: one argv[2]: two argv[3]: three argv[4]:! : דוגמה להרצה של test 21
דוגמה מעניינת יותר לשם מה אנו מקבלים את שם התוכנית עצמה ב-[] argv? נניח כתבנו תוכנית המקבלת שני פרמטרים משורת הפקודה (לא כולל שם התוכנית עצמה). במקרה שמספר הפרמטרים שגוי, נדפיס הודעה ונצא: int main(int argc, char *argv[]) { if (argc!= 3) printf("usage: %s <infile> <outfile>\n", argv[0]); return 0; C:\> testprog one two three four Usage: testprog <infile> <outfile> C:\> דוגמה להרצה: 22
עוד דוגמה: שוב תוכנית החיפושיות נשנה את תוכנית החיפושיות, כך שהיא תדפיס רק את הפרטים של חברי הלהקה שהמשתמש ציין את מספרם בשורת הפקודה (כל חבר להקה מצוין על ידי מספר בין 0 ל- 3, לפי המיקום שלו במערך): 23 int main(int argc, char *argv[]) { int i, num; char *beatles[4] = { ; for (i=1; i<argc; ++i) { int num = atoi(argv[i]); if (num>=0 && num<=3) printf("you have chosen %s!\n", beatles[num]); else printf("no such beatle!\n"); return 0; atoi() מקבלת מחרוזתהמכילהמספר שלם,ומחזירהאתהערך המספרישלו.מוגדרת ב-< stdlib.h >
עוד דוגמה: שוב תוכנית החיפושיות דוגמה להרצת התוכנית: C:\> beatles 2 0 2 3 6 1 You have chosen George Harrison! You have chosen John Lennon! You have chosen George Harrison! You have chosen Ringo Starr! Not a beatle! You have chosen Paul McCartney! 24
שאלות ממבחנים 25
שאלה 1 void interesting(int n) { int* b = NULL; int m = 0, i = n; while (i > 1) { m += n; i /= 2; printf("%d\n", m); i = n; while (i > 4) { b = (int*)malloc(n*sizeof(int)); for (j=0; j<m; ++j) { b[j%n] = j; i /= 5; free(b); מה תדפיס שורת ה- printf בפונקציה עבור הקריאה?interesting(16) סיבוכיות זמן: Ө( n log 2 n ) סיבוכיות מקום Ө( n ) נוסף: 26
שאלה 2 בשאלה זו נתון מערך []a באורך n (לא בהכרח ממוין) המכיל מספרים שלמים. כמו כן ידוע כי ערכי המערך כולם בטווח בין 0 ל- 1-k (כולל). נאמר שהמערך []a חוקי אם אין בו ערכים שחוזרים על עצמם, וכן אין בו ערכים עוקבים. כלומר, אם הערך x מופיע במערך, אזי הוא מופיע בו בדיוק פעם אחת, והערכים 1+x,1-x לא מופיעים כלל במערך. למשל, המערך 17 { 5, 1, 8, = a[] חוקי לעומת זאת, המערך 5 {,5,1,8 = []b אינו חוקי כי הערך 5 חוזר על והמערך 2 { 5, 1, 8, = c[] אינו חוקי כי 1 ו- 2 הם מספרים עוקבים. עצמו. ממשו את הפונקציה,islegal שמקבלת את המערך []a, את אורכו n, ואת הערך k, ומחזירה 1 במידה והמערך חוקי, או 0 אחרת. שימו לב שמערך ריק הוא חוקי. על הפונקציה לעמוד בסיבוכיותזמן.O(n+k) אין הגבלה על סיבוכיות המקום. פתרונות בסיבוכיות זמן גרועה מהנדרש יזכו לניקוד חלקי במידה והם נכונים. שימו לב שבשאלה זו k אינונחשבכקבוע לצרכי חישובי סיבוכיות. 27
שאלה 2 רעיון 1: נמיין את המערך (ע"י מיון מיזוג) ועבור כל מספר נוודא שהשכנים שלו אינם עוקבים או שווים אליו. סיבוכיות מקום: Θ(n) סיבוכיות זמן: n) Θ(n log רעיון 2: נבנה היסטוגרמה בגודל K עם כל המספרים במערך a, ונעבור על ה- K איברי ההיסטוגרמה ונוודא שכולם קטנים ב- 2 ושאין שאיברים עוקבים ממולאים. סיבוכיות מקום: Θ(k) סיבוכיות זמן: k) Θ(n + 28
פתרון שאלה 2 int islegal(int a[], unsigned int n, unsigned int k) { unsigned int i = 0; int *hist; מערך עד גודל 1 הוא תמיד חוקי if (n <= 1) return 1; hist = (int*)malloc(sizeof(int)*k); if (hist == NULL) { printf("allocation error\n"); return -1; for (i=0; i<k; i++) hist[i] = 0; מקצים מקום להיסטוגרמה מאפסים את ההיסטוגרמה 29
פתרון שאלה 2 for (i=0; i<n; i++) hist[a[i]]++; חישוב ההיסטוגרמה for (i=0; i<k; i++) if ((hist[i]>1) (i<k-1 && hist[i]==1 && hist[i+1]>0)) { free(hist); return 0; מוודאים האם יש 2 איברים שווים או שאין עוקבים. free(hist); return 1; משחררים זכרון לפני לצאת מהפונקציה 30
שאלה 3 באי הנידח Sfatsea שבאוקיאנוס ההודי חיים N אנשים, המקיימים ביניהם מערכת חברתית סבוכה: כל תושב באי חבר עם תושבים מסויים,ולכן מותר לו להתגורר רק עם תושבים חברים. לאחרונה החליטו באי לעבור להתגורר בבתים חדשים, ולצורך כך הם בנו k בתים. כעת, עליהם להחליט באיזה בית יגור כל תושב באי, תוך התחשבות בחברויות שלהם. בשאלה זו נכתוב פונקציה המסייעת לתושבי האי למצוא סידור מגורים. הקלט של הפונקציה הינו מספר הבתים שנבנו k, מספר האנשים המקסימאלי בכל בית m, וכן מטריצה a[n][n] (N מוגדר כ- define #) המתארת למי מותר לגור עם מי: a[i][j] מכיל 1 במידה והתושבים i ו- j יכולים לגור יחד, ו- 0 אם לא. שימו לב שהמטריצה a סימטרית, כלומר אם a[i][j]==1 אז בהכרח גם.a[j][i]==1 על הפונקציה למצוא חלוקת חוקית כלשהי של התושבים לבתים, או להודיע שלא קיימת כזו. במידה וישנה חלוקה חוקית, הפונקציה מחזירה 1, וכותבת חלוקה אפשרית כלשהי לתוך מערך הפלט []h שאורכו N, כאשר h[i] מכיל את מספר הבית שאליו משוייך התושב i, בין 0 ל- 1-k. במידה ואין כל חלוקה חוקית, הפונקציה מחזירה 0 ואין חשיבות לתוכן המערך []h. 31
פתרון שאלה 3 רעיון 1: נקטין את הבעיה בהכנסת תושב מסויים לבית כלשהו. נניח שהפונקציה מחזירה 1 אם מצאה פתרון או 0 אחרת. נכניס את התושב הנוכחי לבית,house ונחפש מקום לשאר התושבים..3 אם מצאנו פתרון אז נעצור את החיפוש ונחזיר 1. אחרת נכניס את התושב הנוכחי לבית הבא.house+1 אם הכנסנו אותו לכל הבתים ולא מצאנו פתרון, נחזיר 0. תנאי עצירה: כש- N התושבים הוכנסו לבתים 4. עלינו לוודא שאין יותר מ- m תושבים לבית. עלינו לוודא שרק חברים נמצאים ביחד (נסתכל על מטריצה a) האם יתכן שהשתמשנו ביותר מ- k בתים?.1.2 32
פתרון שאלה 3 מה הבעיה עם הפתרון הראשון? הפתרון האחרון עובר על כל האפשרויות אבל רק בתנאי עצירה מוודא שיש פתרון חוקי. יתכן שהינו יכולים לעצור את הרקורסיה לפני זה? איך? רעיון: נוודא שיש מקום ריק בבית.house אחרת נמשיך עם הבית הבא. רעיון נוסף: נכניס את התושב הנוכחי לבית house אם יש חברים בתוכו. רק 33
פתרון שאלה 3 רעיון 2:.1.2.3 נקטין את הבעיה בהכנסת תושב מסויים לבית כלשהו. נניח שהפונקציה מחזירה 1 אם מצאה פתרון או 0 אחרת. נכניס את התושב הנוכחי לבית house אם יש מקום ויש רק חברים בתוכו, ונחפש מקום לשאר התושבים. אם מצאנו פתרון אז נעצור את החיפוש ונחזיר 1. אחרת נמשיך עם הבית הבא. אם הכנסנו אותו לכל הבתים האפשריים ולא מצאנו פתרון, נחזיר 0..4 תנאי עצירה: כש- N התושבים הוכנסו לבתים. האם צריך לבדוק משהו? 34
פתרון שאלה 3 נתחיל בפונקצית המעטפת: נתחיל עם תושב 0. int housing(int a[n][n], int k, int m, int h[]) { return housing_aux(a,k,m,h,0); המערך h להחזרת הפתרון. נשתמש בו לשמירת פתרון חלקי. האם יש צורך לאתחל אותו? 35
פתרון שאלה 3 int housing_aux(int a[n][n], int k, int m, int h[], int p) { int house, i, count; if (N == p) return 1; for (house=0; house<k; house++) { count = 0; for (i=0; i<p; i++) { if (h[i] == house) { if (!a[p][i]) { count = m; break; count++; if (count >= m) continue; h[p] = house; if (housing_aux(a,k,m,h,p+1)) return 1; return 0; תנאי עצירה: כל התושבים בבתים. נעבור על כל הבתים ננסה להכניס את תושב לבית house האם כולם חברים ויש פחות מ- m תושבים בבית? אם לא נמשיך לבית הבא אם כן נכניס אותו לבית האם יש פתרון לכל שאר התושבים? 36
בהצלחה במבחן! 37